import ipaddress
import logging
import re
import secrets
import struct
from datetime import datetime
from typing import Any, List, Tuple, Sequence
from urllib import parse as urlparse

import eth_keys
import rlp
from eth_keys import datatypes, keys
from eth_typing import Hash32
from eth_utils import to_bytes, keccak, decode_hex

from .config import *


def int_to_big_endian4(value: int) -> bytes:
    """ 4 bytes big endian integer"""
    return struct.pack('>I', value)


def enc_port(p: int) -> bytes:
    return int_to_big_endian4(p)[-2:]


def int_to_big_endian(value: int) -> bytes:
    return value.to_bytes((value.bit_length() + 7) // 8 or 1, "big")


def big_endian_to_int(value: bytes) -> int:
    return int.from_bytes(value, "big")


def remote_to_str(remote: Tuple[str, int, int]) -> str:
    return remote[0] + ':' + str(remote[1])


def pack_v4(cmd_id: int, payload: Sequence[Any], privkey) -> bytes:
    """Create and sign a UDP message to be sent to a remote node.

    See https://github.com/ethereum/devp2p/blob/master/rlpx.md#node-discovery for information on
    how UDP packets are structured.
    """
    cmd_id_bytes = to_bytes(cmd_id)
    encoded_data = cmd_id_bytes + rlp.encode(payload)
    signature = privkey.sign_msg(encoded_data)
    message_hash = keccak(signature.to_bytes() + encoded_data)
    return message_hash + signature.to_bytes() + encoded_data


def unpack_v4(message: bytes) -> Tuple[datatypes.PublicKey, int, Tuple[Any, ...], Hash32]:
    """Unpack a discovery v4 UDP message received from a remote node.

    Returns the public key used to sign the message, the cmd ID, payload and hash.
    """
    message_hash = Hash32(message[:MAC_SIZE])
    if message_hash != keccak(message[MAC_SIZE:]):
        raise SyntaxError("Wrong msg mac")
    signature = eth_keys.keys.Signature(message[MAC_SIZE:HEAD_SIZE])
    signed_data = message[HEAD_SIZE:]
    remote_pubkey = signature.recover_public_key_from_msg(signed_data)
    cmd_id = message[HEAD_SIZE]
    payload = tuple(rlp.decode(message[HEAD_SIZE + 1:], strict=False))
    return remote_pubkey, cmd_id, payload, message_hash


def check_relayed_addr(sender: ipaddress, addr: ipaddress) -> bool:
    """Check if an address relayed by the given sender is valid.

    Reserved and unspecified addresses are always invalid.
    Private addresses are valid if the sender is a private host.
    Loopback addresses are valid if the sender is a loopback host.
    All other addresses are valid.
    """
    if addr.is_unspecified or addr.is_reserved:
        return False
    if addr.is_private and not sender.is_private:
        return False
    if addr.is_loopback and not sender.is_loopback:
        return False
    return True


def extract_nodes_from_payload(
        sender: Tuple[str, int, int],
        payload: List[Tuple[str, bytes, bytes, bytes]],
        logger: logging) -> List[Tuple[Tuple[str, int, int], keys.PublicKey]]:
    res = []
    sender_ip = ipaddress.ip_address(sender[0])
    for item in payload:
        ip, udp_port, tcp_port, node_id = item
        addr_ip = ipaddress.ip_address(ip)
        if check_relayed_addr(sender_ip, addr_ip):
            res.append(((addr_ip.exploded, big_endian_to_int(udp_port), big_endian_to_int(tcp_port)),
                        eth_keys.keys.PublicKey(node_id)))
        else:
            logger.debug("Skipping invalid address %s relayed by %s", addr_ip.exploded, sender[0])
    return res


def validate_enode_uri(enode: str, require_ip: bool = False, logger: logging = logging) -> bool:
    try:
        parsed = urlparse.urlparse(enode)
    except ValueError as e:
        return False

    if parsed.scheme != 'enode' or not parsed.username:
        logger.warning('enode string must be of the form "enode://public-key@ip:port" for %s', enode)
        return False

    if not re.match('^[0-9a-fA-F]{128}$', parsed.username):
        logger.warning('Public key must be a 128-character hex string for %s', enode)
        return False

    decoded_username = decode_hex(parsed.username)

    try:
        ip = ipaddress.ip_address(parsed.hostname)
    except ValueError as e:
        logger.warning(str(e))
        return False

    if require_ip and ip in (ipaddress.ip_address('0.0.0.0'), ipaddress.ip_address('::')):
        logger.warning('A concrete IP address must be specified for %s', enode)
        return False

    keys.PublicKey(decoded_username)

    try:
        # this property performs a check that the port is in range
        parsed.port
    except ValueError as e:
        logger.warning(str(e))
        return False


def from_enode_uri(uri: str, logger: logging = logging) -> Tuple[Tuple[str, int, int], datatypes.PublicKey]:
    validate_enode_uri(uri, False, logger)  # Be no more permissive than the validation
    parsed = urlparse.urlparse(uri)
    pubkey = keys.PublicKey(decode_hex(parsed.username))
    return (parsed.hostname, parsed.port, parsed.port), pubkey


def random_lookup() -> bytes:
    """ Generate a random node public key to lookup.
    """
    return int_to_big_endian(
        secrets.randbits(KADEMLIA_PUBLIC_KEY_SIZE)
    ).rjust(KADEMLIA_PUBLIC_KEY_SIZE // 8, b'\x00')


def formatted_date() -> str:
    return datetime.utcnow().strftime('%Y-%m-%d %H:%M:%S.%f')[:-3]
